Claiming: sign in with a passkey, publish a token, be verified - #16
Merged
Conversation
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
…eans Spec §8 rewritten around one decision -- sign in first, then claim -- and the storage and crawler halves built against it. The web half (passkeys, the pages) is not here yet. The ordering is not a convenience. We ask an operator to publish the token on a connect screen or in an MSSP field, where every anonymous connection reads it, so a design in which holding the token confers the claim is broken the instant it succeeds. The token is a NONCE: it proves somebody with write access to that server published it, and the account column answers the separate question of who asked. Mallory reading Alice's token off the connect screen can do nothing with it, and there is a test that says so by name. That also answers what happens between minting and verifying. The pending claim is durable server-side state shown on the claimant's dashboard, so nobody has to write a token down or finish in one sitting -- a scheme that punished the person who closed the tab would put a transcription error between an owner and their listing. Sign-in will be passkeys and nothing else (§8.2). No passwords, no email, no federated provider. The usual blocker for passwordless is account recovery, and it does not apply here: the root of trust is the server the operator controls, so losing every device is recoverable by publishing a fresh token. Two consequences are written down rather than discovered later -- sign-in will be the only part of this site needing JavaScript, and a passkey is bound to a domain, which gives §15.1's open domain question a deadline. §8.4 is the other rule with teeth: presence establishes, absence never revokes. Two timestamps, because they are two facts. Absence-revokes would hand revocation to any transient failure, and this project has watched MCCP swallow a connection's payload whole -- a silent unclaiming on that basis is indistinguishable from an owner walking away. Three guarantees are in the schema rather than in a handler, because a constraint that fires is a guarantee and a branch in C# is an intention: a claim carries a NOT NULL account, one account holds at most one pending claim per game (partial unique index), and a token is unique across the table -- without which one game's published token could complete another game's claim. DNS TXT is deferred, and not for want of a resolver: a TXT record proves control of a hostname, and a hostname is not a game. MU* hosting routinely puts many unrelated games on one domain separated by port, so the host's operator could claim all of them and a game on somebody else's domain could not use the channel at all. Also here, because it was in the way: PostgresFixture ran Postgres at its default 100 connections, and this suite passed that the day it grew past roughly that many tests. The failure was not a clean one -- whichever tests happened to be starting failed with 53300, in twenty unrelated places, reading as flakiness rather than as a ceiling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ASP.NET Core Identity's default store is EF Core. Bringing it in for four tables would be the wrong trade in a codebase that has kept its SQL visible and its migrations hand-numbered, and the whole of what Identity needs is two interfaces -- IUserStore and IUserPasskeyStore -- which is a page of SQL. migrations/0007 stays the single description of these tables. Only what this app uses is implemented. There is no IUserPasswordStore, no IUserEmailStore, no lockout and no two-factor store, because there are no passwords, no addresses and no second factor -- a passkey is a primary factor (§8.2). An unimplemented interface here is a feature we do not have rather than a gap: adding the password store would make UserManager start offering flows this site has no pages for. The schema gained two columns after reading Identity's actual contract rather than guessing at it: UserPasskeyInfo carries client_data_json and is_user_verified, and a store that drops half a record hands back something that is not what was registered. Transports is a text[] so a round trip cannot re-delimit what the authenticator reported. Four schema guarantees are now pinned where they are enforced. Deleting an account takes its passkeys and is refused while it still owns a claim -- the asymmetry is deliberate, because a passkey is a way in and a claim is what tells the world a game is owned. The pending-claim index is partial, so a verified or revoked claim does not lock an account out of a game it can still prove control of. And the audit vocabulary is checked in both directions: a CHECK that refuses a value the code can produce fails in production rather than in a test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ified
The web half of §8. Sign-in is passkeys and nothing else -- no passwords, no
email, no federated provider -- over ASP.NET Core Identity's built-in WebAuthn
support, with the Dapper stores from the previous commit behind it.
Four endpoints and one script file. That script is the only JavaScript on this
site, and the boundary is deliberate: navigator.credentials has no scripting-off
path, so the part that needs it is the part used by people who administer a game
server, while the catalogue, the game pages, the archive, plain mode and the API
all keep working with scripting disabled.
The claim page answers the question the design turns on -- what happens between
minting a token and verifying it. Nothing needs writing down. The pending claim
is durable server-side state shown for as long as it is pending, with each
channel's line ready to copy, and IssueAsync returns the existing token rather
than replacing it, so a refresh cannot invalidate what the operator has just
finished pasting into mush.cnf.
Everything to do with accounts is registered only when a connection string is,
and the surfaces are absent rather than broken without one. Half a claim flow
over invented games would be worse than none: an operator following it would
publish a token on a real server for a listing that is not their game.
Two things were missing and are now here rather than worked around.
IGameQueries had no by-id lookup, so an owner page had to reach past the
interface to a store or resolve a slug it was never given -- a claim is bound to
the game, not to a name a rename can move. And the test harness had never
rendered a whole page; Render.PageAsync wires the fixture and deliberately not
the account services, which is the condition every claim-surface test is about.
Verified against a real database rather than a build: migration 0007 applies,
/account/sign-in, /g/{slug}/claim and /account all answer, the game page offers
the claim only when there is somewhere to put it, and the assertion endpoint
returns a real WebAuthn challenge.
Five suites, 652 tests, zero warnings.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HarryCordewener
changed the base branch from
chore/park-the-importer
to
feat/reference-pages
July 31, 2026 15:32
HarryCordewener
force-pushed
the
feat/claiming
branch
from
July 31, 2026 15:32
f3cfcd7 to
4d06c87
Compare
HarryCordewener
added a commit
that referenced
this pull request
Aug 15, 2026
* Claiming was complete, tested, and wired to nothing §8.5 says nothing sets game.is_claimed, so the listing badge and §7.5's ceiling grace have never been exercised. Claiming shipped in #16 and the note still read true, so I went looking for why. The claim logic was not the problem — it is complete and it has tests. The composition was. The site has two compositions of the same objects. mui-crawl builds the crawl loop by hand and passes every collaborator; the deployed site assembles it through DI. Three things were wrong with the second, and none of them could fail loudly: CrawlCycle takes its ClaimService as an OPTIONAL parameter, deliberately — a crawl with no database should do slightly less rather than refuse to run. The crawler graph registered no ClaimService, so a crawler-only deployment settled no beacons and said nothing about it. ClaimService was registered scoped, and the crawl loop that needs it is a singleton BackgroundService. A scoped dependency is one CrawlCycle can never legally be given: with scope validation on, which is what `dotnet run` does, the container refuses to build and THE SITE DOES NOT START with a connection string set. Production leaves validation off, so there it worked — by accident, and only there. That is also the answer to §8.5's note: is_claimed was being set in production and nowhere else. IClaimStore was registered nowhere at all. Account.razor service-locates it and reads a null as "this site has no database", so every operator's dashboard was empty on a site that had their claims, and /g/{slug}/claim/check threw on request. A service-located dependency fails silently by construction. Both services are stateless over a pooled NpgsqlDataSource, so both are now TryAddSingleton, registered by the crawler graph and the accounts graph alike — the one deployment that runs both gets one of each. CompositionTests resolves the graph Program builds, under scope validation, in both environments, and asserts the claim path is really joined. It fails on the parent commit in seven ways and is the only kind of test that could have caught any of this: every part was correct and the wiring between them was not. §8.5's closing note is rewritten to say what is actually true, and to name the general hazard — an optional dependency and a service-located one both fail silently when the composition is wrong, and the claim path has one of each. 830 tests over five suites, Postgres exercised. Testcontainers 4.13.0 -> 4.14.0 is the same one-line pickup as #26: SSH.NET 2025.1.0 is now advised against and NU1903 fails restore on main without it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Compose the test from Program's own graph, and make "look sooner" look Review findings on the composition fix. All four held; each is verified rather than taken. CompositionTests restated Program's registrations instead of running them, so a future divergence — a scoped service consumed by a singleton, AddMuiAccounts moving — would break the site while all the tests passed. That is exactly the failure this file exists to catch. The graph moves out of Program's top-level statements into SiteComposition.AddMuiSite/UseMuiSite, which the deployable and the test now both call, so there is one copy and nothing to diverge from. The on-demand check moved nothing. RequestCheckAsync wrote last_checked_at and a check_requested event; due-ness comes only from crawl_target.next_probe_at, so no probe ever came of it while the page said the button dialled a real server. A rate limiter on an action that does not happen is the most convincing possible no-op. IOnDemandProbes brings the game's targets forward with LEAST — an ask can only make a probe sooner — and the crawl loop still does the dialling under CRAWL DELAY and §7.2's address gate. Five Postgres tests assert on the schedule rather than on the audit log. The button now says what it does: "Look sooner", brings your game to the front of the queue, we dial on our own schedule. The remark on TheHostedCrawlerCanBeResolvedInProductionToo described a failure that never happened — it passes on the parent commit, because Production leaves scope validation off and resolved the scoped ClaimService from the root. It is a control, not a finding, and now says so. Measured: the original seven tests fail five ways on the parent, not seven. With the four added here, ten fail seven. "A crawler-only deployment" justified the new registrations in three places and describes nothing: AddMuiCrawler has exactly one caller, MUI.Web's Program, and mui-crawl builds its graph by hand. §4.11 has one deployable. The real consumer is the web tier's in-process CrawlCycle, and all three now say so. Two more while here. The demo composition had no test at all; it has one, and it asserts the claim surfaces are absent rather than broken and that the page still admits nothing on it was measured. And the web tier registered its own NpgsqlAvailabilityStore with AddSingleton, so the crawler's TryAdd was skipped and the concrete type and IReachableHistory pointed at a second instance — harmless on one pool and a direct contradiction of the crawler's own comment that two would be two connection paths answering one question. AddPostgresCatalogue is TryAdd throughout now, and one object answers to all three names. 838 tests over five suites, Postgres exercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Drive the passkey ceremony for real, because nothing ever had #30 reported that a JSON POST to a minimal API mapped after UseAntiforgery() is refused with 400 in a slim host, even with .DisableAntiforgery(), which would mean passkey sign-in is broken and §8.2 leaves no other way in. IT DOES NOT REPRODUCE. Measured against the real host — accounts registered, Postgres behind them, migrations applied, ASPNETCORE_ENVIRONMENT=Production: POST /account/passkey/assertion-options -> 200 POST /account/passkey/sign-in (as sent) -> 401 POST /account/passkey/registration-options -> 200 POST /account (Razor route, no token) -> 400 <- control The control is the part that makes the rest mean anything: anti-forgery IS live in that pipeline and refuses an untokened POST one route over. The 401 is the handler answering, not the middleware — the first attempt without the ceremony's cookie got a 500 from SignInManager saying no assertion was underway, which is the handler too. The mechanism: a minimal API is given anti-forgery metadata only when it binds FORM data. These bind JSON or a query string, so they carry none and the middleware passes them through; MapRazorComponents puts metadata on component routes, which is why the control is refused. And .DisableAntiforgery() sets RequiresValidation false — a 400 surviving it was never anti-forgery's. Adding the test anyway. Sign-in is the only door in the building and no suite opened it: the ones that exercise sign-in are the ones that stub it, and the composition tests stop at the graph. These drive the real routes through AddMuiSite/UseMuiSite, with a control that fails if anti-forgery ever stops being live and would catch the reported failure if it ever became real. 841 tests over five suites, Postgres exercised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #11. Spec §8 rewritten around one decision — sign in first, then claim — and built end to end.
The decision that shapes everything
The token cannot be a secret, because we ask operators to publish it on a connect screen or in an MSSP field, where every anonymous connection reads it — including every other crawler. So a bearer model, where holding the token confers the claim, is broken the instant it succeeds.
It is a nonce. It proves somebody with write access to that server published it; the account it is bound to answers who asked. Mallory reads Alice's token off the connect screen and can do nothing with it — there is a test named for exactly that.
That also answers what happens between minting and verifying: nothing needs writing down. The pending claim is durable server-side state, shown for as long as it is pending, with each channel's line ready to copy.
IssueAsyncreturns the existing token rather than replacing it, so a refresh cannot invalidate what an operator has just finished pasting intomush.cnf.Sign-in is passkeys and nothing else
No passwords, no email, no federated provider. The usual blocker for passwordless is account recovery, and it does not apply here: the root of trust is the server the operator controls, not the credential. Lose every device, publish a fresh token, done. An account is correspondingly worth almost nothing to steal.
Identity runs over Dapper, not EF Core —
IUserStore+IUserPasskeyStoreis a page of SQL, andmigrations/0007_ownership.sqlstays the single description of these tables.IUserPasswordStore,IUserEmailStore, lockout and two-factor are deliberately unimplemented: each absent interface is a feature we do not have, and adding the password store would makeUserManagerstart offering flows this site has no pages for.One script file, and it is the only JavaScript on this site.
navigator.credentialshas no scripting-off path, so the boundary is drawn at sign-in; the catalogue, game pages, archive, plain mode and API all keep working with scripting disabled.Rules with teeth
NOT NULLaccount; one account holds at most one pending claim per game (partial index, so a verified or revoked claim does not lock an account out of a game it can still prove control of); a token is unique table-wide, without which one game's published token could complete another's.Two gaps closed rather than worked around
IGameQuerieshad no by-id lookup, so an owner page had to reach past the interface or resolve a slug it was never given — a claim binds to the game, not to a name a rename can move. And the test harness had never rendered a whole page;Render.PageAsyncwires the fixture and deliberately not the account services, which is the condition every claim-surface test is about.Verified against a real database
Migration 0007 applies;
/account/sign-in,/g/{slug}/claimand/accountall answer; the game page offers the claim only when there is somewhere to put it; the assertion endpoint returns a real WebAuthn challenge.Five suites, 652 tests, zero warnings.
Known and deliberate
Passkeys:ServerDomainis unset in development, so the RP ID falls back to the host. It must be configured before deployment — a passkey is bound to a domain, which gives §15.1's open domain question a deadline: credentials registered before the domain settles have to be registered again after it moves.🤖 Generated with Claude Code
https://claude.ai/code/session_01NrGKmKcRCGktyhRTFbQDMk